import _ from 'lodash';
import path from 'path';
import url from 'url';
import logger from './logger';
import {tempDir, fs, util, timing, node} from '@appium/support';
import { LRUCache } from 'lru-cache';
import AsyncLock from 'async-lock';
import axios from 'axios';
import B from 'bluebird';

// for compat with running tests transpiled and in-place
export const {version: BASEDRIVER_VER} = fs.readPackageJsonFrom(__dirname);
const CACHED_APPS_MAX_AGE_MS = 1000 * 60 * toNaturalNumber(60 * 24, 'APPIUM_APPS_CACHE_MAX_AGE');
const MAX_CACHED_APPS = toNaturalNumber(1024, 'APPIUM_APPS_CACHE_MAX_ITEMS');
const HTTP_STATUS_NOT_MODIFIED = 304;
const DEFAULT_REQ_HEADERS = Object.freeze({
  'user-agent': `Appium (BaseDriver v${BASEDRIVER_VER})`,
});
const AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC = 2;
/** @type {LRUCache<string, import('@appium/types').CachedAppInfo>} */
const APPLICATIONS_CACHE = new LRUCache({
  max: MAX_CACHED_APPS,
  ttl: CACHED_APPS_MAX_AGE_MS, // expire after 24 hours
  updateAgeOnGet: true,
  dispose: ({fullPath}, app) => {
    logger.info(
      `The application '${app}' cached at '${fullPath}' has ` +
        `expired after ${CACHED_APPS_MAX_AGE_MS}ms`
    );
    if (fullPath) {
      fs.rimraf(fullPath);
    }
  },
  noDisposeOnSet: true,
});
const APPLICATIONS_CACHE_GUARD = new AsyncLock();
const SANITIZE_REPLACEMENT = '-';
const DEFAULT_BASENAME = 'appium-app';
const APP_DOWNLOAD_TIMEOUT_MS = 120 * 1000;

process.on('exit', () => {
  if (APPLICATIONS_CACHE.size === 0) {
    return;
  }

  const appPaths = [...APPLICATIONS_CACHE.values()].map(({fullPath}) => fullPath);
  logger.debug(
    `Performing cleanup of ${appPaths.length} cached ` +
      util.pluralize('application', appPaths.length)
  );
  for (const appPath of appPaths) {
    try {
      // @ts-ignore it's defined
      fs.rimrafSync(appPath);
    } catch (e) {
      logger.warn(e.message);
    }
  }
});

/**
 * Perform initial application package configuration
 * to prepare it for the further consumption by a driver:
 *
 * - Manages caching logic
 * - Downloads the app from a remote URL to the local filesystem
 * - Determines package name
 * - Checks basic requirements on the application package
 *
 * @param {string} app
 * @param {string|string[]|import('@appium/types').ConfigureAppOptions} options
 * @returns {Promise<string>}
 */
export async function configureApp(
  app,
  options = /** @type {import('@appium/types').ConfigureAppOptions} */ ({})
) {
  if (!_.isString(app)) {
    // immediately shortcircuit if not given an app
    return '';
  }

  /** @type {string[]} */
  let supportedAppExtensions;
  const onPostProcess = !_.isString(options) && !_.isArray(options) ? options.onPostProcess : undefined;
  const onDownload = !_.isString(options) && !_.isArray(options) ? options.onDownload : undefined;

  if (_.isString(options)) {
    supportedAppExtensions = [options];
  } else if (_.isArray(options)) {
    supportedAppExtensions = options;
  } else if (_.isPlainObject(options)) {
    supportedAppExtensions = options.supportedExtensions;
  }
  // @ts-ignore this is OK
  if (_.isEmpty(supportedAppExtensions)) {
    throw new Error(`One or more supported app extensions must be provided`);
  }

  let newApp = app;
  const originalAppLink = app;
  let packageHash = null;
  /** @type {import('axios').AxiosResponse['headers']|undefined} */
  let headers;
  /** @type {RemoteAppProps} */
  const remoteAppProps = {
    lastModified: null,
    immutable: false,
    maxAge: null,
    etag: null,
  };
  const {protocol, pathname} = parseAppLink(app);
  const isUrl = isSupportedUrl(app);
  if (!isUrl && !path.isAbsolute(newApp)) {
    newApp = path.resolve(process.cwd(), newApp);
    logger.warn(
      `The current application path '${app}' is not absolute ` +
      `and has been rewritten to '${newApp}'. Consider using absolute paths rather than relative`
    );
    app = newApp;
  }
  const appCacheKey = toCacheKey(app);

  return await APPLICATIONS_CACHE_GUARD.acquire(appCacheKey, async () => {
    const cachedAppInfo = APPLICATIONS_CACHE.get(appCacheKey);
    if (cachedAppInfo) {
      logger.debug(`Cached app data: ${JSON.stringify(cachedAppInfo, null, 2)}`);
    }

    if (isUrl) {
      // Use the app from remote URL
      logger.info(`Using downloadable app '${newApp}'`);
      const reqHeaders = {
        ...DEFAULT_REQ_HEADERS,
      };
      if (cachedAppInfo?.etag) {
        reqHeaders['if-none-match'] = cachedAppInfo.etag;
      } else if (cachedAppInfo?.lastModified) {
        reqHeaders['if-modified-since'] = cachedAppInfo.lastModified.toUTCString();
      }
      logger.debug(`Request headers: ${JSON.stringify(reqHeaders)}`);

      let {headers, stream, status} = await queryAppLink(newApp, reqHeaders);
      logger.debug(`Response status: ${status}`);
      try {
        if (!_.isEmpty(headers)) {
          if (headers.etag) {
            logger.debug(`Etag: ${headers.etag}`);
            remoteAppProps.etag = headers.etag;
          }
          if (headers['last-modified']) {
            logger.debug(`Last-Modified: ${headers['last-modified']}`);
            remoteAppProps.lastModified = new Date(headers['last-modified']);
          }
          if (headers['cache-control']) {
            logger.debug(`Cache-Control: ${headers['cache-control']}`);
            remoteAppProps.immutable = /\bimmutable\b/i.test(headers['cache-control']);
            const maxAgeMatch = /\bmax-age=(\d+)\b/i.exec(headers['cache-control']);
            if (maxAgeMatch) {
              remoteAppProps.maxAge = parseInt(maxAgeMatch[1], 10);
            }
          }
        }
        if (cachedAppInfo && status === HTTP_STATUS_NOT_MODIFIED) {
          if (await isAppIntegrityOk(/** @type {string} */ (cachedAppInfo.fullPath), cachedAppInfo.integrity)) {
            logger.info(`Reusing previously downloaded application at '${cachedAppInfo.fullPath}'`);
            return verifyAppExtension(/** @type {string} */ (cachedAppInfo.fullPath), supportedAppExtensions);
          }
          logger.info(
            `The application at '${cachedAppInfo.fullPath}' does not exist anymore ` +
              `or its integrity has been damaged. Deleting it from the internal cache`
          );
          APPLICATIONS_CACHE.delete(appCacheKey);

          if (!stream.closed) {
            stream.destroy();
          }
          ({stream, headers, status} = await queryAppLink(newApp, {...DEFAULT_REQ_HEADERS}));
        }

        if (onDownload) {
          newApp = await onDownload({
            url: originalAppLink,
            headers: /** @type {import('@appium/types').HTTPHeaders} */ (_.clone(headers)),
            stream,
          });
        } else {
          const fileName = determineFilename(headers, pathname, supportedAppExtensions);
          newApp = await fetchApp(stream, await tempDir.path({
            prefix: fileName,
            suffix: '',
          }));
        }
      } finally {
        if (!stream.closed) {
          stream.destroy();
        }
      }
    } else if (await fs.exists(newApp)) {
      // Use the local app
      logger.info(`Using local app '${newApp}'`);
    } else {
      let errorMessage = `The application at '${newApp}' does not exist or is not accessible`;
      // protocol value for 'C:\\temp' is 'c:', so we check the length as well
      if (_.isString(protocol) && protocol.length > 2) {
        errorMessage =
          `The protocol '${protocol}' used in '${newApp}' is not supported. ` +
          `Only http: and https: protocols are supported`;
      }
      throw new Error(errorMessage);
    }

    const isPackageAFile = (await fs.stat(newApp)).isFile();
    if (isPackageAFile) {
      packageHash = await calculateFileIntegrity(newApp);
    }

    /** @type {(appPathToCache: string) => Promise<string>} */
    const storeAppInCache = async (appPathToCache) => {
      const cachedFullPath = cachedAppInfo?.fullPath;
      if (cachedFullPath && cachedFullPath !== appPathToCache) {
        await fs.rimraf(cachedFullPath);
      }
      const integrity = {};
      if ((await fs.stat(appPathToCache)).isDirectory()) {
        integrity.folder = await calculateFolderIntegrity(appPathToCache);
      } else {
        integrity.file = await calculateFileIntegrity(appPathToCache);
      }
      APPLICATIONS_CACHE.set(appCacheKey, {
        ...remoteAppProps,
        timestamp: Date.now(),
        packageHash,
        integrity,
        fullPath: appPathToCache,
      });
      return appPathToCache;
    };

    if (_.isFunction(onPostProcess)) {
      const result = await onPostProcess(
        /** @type {import('@appium/types').PostProcessOptions<import('axios').AxiosResponseHeaders>} */ ({
          cachedAppInfo: _.clone(cachedAppInfo),
          isUrl,
          originalAppLink,
          headers: _.clone(headers),
          appPath: newApp,
        })
      );
      return !result?.appPath || app === result?.appPath || !(await fs.exists(result?.appPath))
        ? newApp
        : await storeAppInCache(result.appPath);
    }

    verifyAppExtension(newApp, supportedAppExtensions);
    return appCacheKey !== toCacheKey(newApp) && (packageHash || _.values(remoteAppProps).some(Boolean))
      ? await storeAppInCache(newApp)
      : newApp;
  });
}

/**
 * @param {string} app
 * @returns {boolean}
 */
export function isPackageOrBundle(app) {
  return /^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+)+$/.test(app);
}

/**
 * Finds all instances 'firstKey' and create a duplicate with the key 'secondKey',
 * Do the same thing in reverse. If we find 'secondKey', create a duplicate with the key 'firstKey'.
 *
 * This will cause keys to be overwritten if the object contains 'firstKey' and 'secondKey'.

 * @param {*} input Any type of input
 * @param {String} firstKey The first key to duplicate
 * @param {String} secondKey The second key to duplicate
 */
export function duplicateKeys(input, firstKey, secondKey) {
  // If array provided, recursively call on all elements
  if (_.isArray(input)) {
    return input.map((item) => duplicateKeys(item, firstKey, secondKey));
  }

  // If object, create duplicates for keys and then recursively call on values
  if (_.isPlainObject(input)) {
    const resultObj = {};
    for (let [key, value] of _.toPairs(input)) {
      const recursivelyCalledValue = duplicateKeys(value, firstKey, secondKey);
      if (key === firstKey) {
        resultObj[secondKey] = recursivelyCalledValue;
      } else if (key === secondKey) {
        resultObj[firstKey] = recursivelyCalledValue;
      }
      resultObj[key] = recursivelyCalledValue;
    }
    return resultObj;
  }

  // Base case. Return primitives without doing anything.
  return input;
}

/**
 * Takes a capability value and tries to JSON.parse it as an array,
 * and either returns the parsed array or a singleton array.
 *
 * @param {string|string[]} capValue Capability value
 * @returns {string[]}
 */
export function parseCapsArray(capValue) {
  if (_.isArray(capValue)) {
    return capValue;
  }

  try {
    const parsed = JSON.parse(capValue);
    if (_.isArray(parsed)) {
      return parsed;
    }
  } catch (e) {
    const message = `Failed to parse capability as JSON array: ${e.message}`;
    if (_.isString(capValue) && _.startsWith(_.trimStart(capValue), '[')) {
      throw new TypeError(message);
    }
    logger.warn(message);
  }
  if (_.isString(capValue)) {
    return [capValue];
  }
  throw new TypeError(`Expected a string or a valid JSON array; received '${capValue}'`);
}

/**
 * Generate a string that uniquely describes driver instance
 *
 * @param {object} obj driver instance
 * @param {string?} [sessionId=null] session identifier (if exists).
 * This parameter is deprecated and is not used.
 * @returns {string}
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function generateDriverLogPrefix(obj, sessionId = null) {
  return `${obj.constructor.name}@${node.getObjectId(obj).substring(0, 4)}`;
}

/**
 * Sends a HTTP GET query to fetch the app with caching enabled.
 * Follows https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
 *
 * @param {string} appLink The URL to download an app from
 * @param {import('axios').RawAxiosRequestHeaders} reqHeaders Additional HTTP request headers
 * @returns {Promise<RemoteAppData>}
 */
async function queryAppLink(appLink, reqHeaders) {
  const {href, auth} = url.parse(appLink);
  const axiosUrl = auth ? href.replace(`${auth}@`, '') : href;
  /** @type {import('axios').AxiosBasicCredentials|undefined} */
  const axiosAuth = auth ? {
    username: auth.substring(0, auth.indexOf(':')),
    password: auth.substring(auth.indexOf(':') + 1),
  } : undefined;
  /**
   * @type {import('axios').RawAxiosRequestConfig}
   */
  const requestOpts = {
    url: axiosUrl,
    auth: axiosAuth,
    responseType: 'stream',
    timeout: APP_DOWNLOAD_TIMEOUT_MS,
    validateStatus: (status) =>
      (status >= 200 && status < 300) || status === HTTP_STATUS_NOT_MODIFIED,
    headers: reqHeaders,
  };
  try {
    const {data: stream, headers, status} = await axios(requestOpts);
    return {
      stream,
      headers,
      status,
    };
  } catch (err) {
    throw new Error(`Cannot download the app from ${axiosUrl}: ${err.message}`);
  }
}

/**
 * Retrieves app payload from the given stream. Also meters the download performance.
 *
 * @param {import('stream').Readable} srcStream The incoming stream
 * @param {string} dstPath The target file path to be written
 * @returns {Promise<string>} The same dstPath
 * @throws {Error} If there was a failure while downloading the file
 */
async function fetchApp(srcStream, dstPath) {
  const timer = new timing.Timer().start();
  try {
    const writer = fs.createWriteStream(dstPath);
    srcStream.pipe(writer);

    await new B((resolve, reject) => {
      srcStream.once('error', reject);
      writer.once('finish', resolve);
      writer.once('error', (e) => {
        srcStream.unpipe(writer);
        reject(e);
      });
    });
  } catch (err) {
    throw new Error(`Cannot fetch the application: ${err.message}`);
  }

  const secondsElapsed = timer.getDuration().asSeconds;
  const {size} = await fs.stat(dstPath);
  logger.debug(
    `The application (${util.toReadableSizeString(size)}) ` +
      `has been downloaded to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`
  );
  // it does not make much sense to approximate the speed for short downloads
  if (secondsElapsed >= AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC) {
    const bytesPerSec = Math.floor(size / secondsElapsed);
    logger.debug(`Approximate download speed: ${util.toReadableSizeString(bytesPerSec)}/s`);
  }

  return dstPath;
}

/**
 * Transforms the given app link to the cache key.
 * This is necessary to properly cache apps
 * having the same address, but different query strings,
 * for example ones stored in S3 using presigned URLs.
 *
 * @param {string} app App link.
 * @returns {string} Transformed app link or the original arg if
 * no transformation is needed.
 */
function toCacheKey(app) {
  if (!isEnvOptionEnabled('APPIUM_APPS_CACHE_IGNORE_URL_QUERY') || !isSupportedUrl(app)) {
    return app;
  }
  try {
    const {href, search} = parseAppLink(app);
    if (href && search) {
      return href.replace(search, '');
    }
    if (href) {
      return href;
    }
  } catch {}
  return app;
}

/**
 * Safely parses the given app link to a URL object
 *
 * @param {string} appLink
 * @returns {URL|import('@appium/types').StringRecord} Parsed URL object
 * or an empty object if the parsing has failed
 */
function parseAppLink(appLink) {
  try {
    return new URL(appLink);
  } catch {
    return {};
  }
}

/**
 * Tries to determine the file name of the payload that is going
 * to be downloaded from an URL
 *
 * @param {import('axios').RawAxiosRequestHeaders} headers
 * @param {string} pathname
 * @param {string[]} supportedAppExtensions
 * @returns {string}
 */
function determineFilename(headers, pathname, supportedAppExtensions) {
  const basename = fs.sanitizeName(path.basename(decodeURIComponent(pathname ?? '')), {
    replacement: SANITIZE_REPLACEMENT,
  });
  const extname = path.extname(basename);
  if (headers['content-disposition'] && /^attachment/i.test(
    /** @type {string} */ (headers['content-disposition']
  ))) {
    logger.debug(`Content-Disposition: ${headers['content-disposition']}`);
    const match = /filename="([^"]+)/i.exec(/** @type {string} */ (headers['content-disposition']));
    if (match) {
      return fs.sanitizeName(match[1], {
        replacement: SANITIZE_REPLACEMENT,
      });
    }
  }

  // assign the default file name and the extension if none has been detected
  const resultingName = basename
    ? basename.substring(0, basename.length - extname.length)
    : DEFAULT_BASENAME;
  let resultingExt = extname;
  if (!supportedAppExtensions.map(_.toLower).includes(_.toLower(resultingExt))) {
    logger.info(
      `The current file extension '${resultingExt}' is not supported. ` +
        `Defaulting to '${_.first(supportedAppExtensions)}'`
    );
    resultingExt = /** @type {string} */ (_.first(supportedAppExtensions));
  }
  return `${resultingName}${resultingExt}`;
}

/**
 * Checks whether we can threat the given app link
 * as a URL,
 *
 * @param {string} app
 * @returns {boolean} True if app is a supported URL
 */
function isSupportedUrl(app) {
  try {
    const {protocol} = parseAppLink(app);
    return ['http:', 'https:'].includes(protocol);
  } catch {
    return false;
  }
}

/**
 * Check if the given environment option is enabled
 *
 * @param {string} optionName Option name
 * @param {boolean|null} [defaultValue=null] The value to return if the given env value
 * is not set explicitly
 * @returns {boolean} True if the option is enabled
 */
function isEnvOptionEnabled(optionName, defaultValue = null) {
  const value = process.env[optionName];
  if (!_.isNull(defaultValue) && _.isEmpty(value)) {
    return defaultValue;
  }
  return !_.isEmpty(value) && !['0', 'false', 'no'].includes(_.toLower(value));
}

/**
 *
 * @param {string} [envVarName]
 * @param {number} defaultValue
 * @returns {number}
 */
function toNaturalNumber(defaultValue, envVarName) {
  if (!envVarName || _.isUndefined(process.env[envVarName])) {
    return defaultValue;
  }
  const num = parseInt(`${process.env[envVarName]}`, 10);
  return num > 0 ? num : defaultValue;
}

/**
 * @param {string} app
 * @param {string[]} supportedAppExtensions
 * @returns {string}
 */
function verifyAppExtension(app, supportedAppExtensions) {
  if (supportedAppExtensions.map(_.toLower).includes(_.toLower(path.extname(app)))) {
    return app;
  }
  throw new Error(
    `New app path '${app}' did not have ` +
      `${util.pluralize('extension', supportedAppExtensions.length, false)}: ` +
      supportedAppExtensions
  );
}

/**
 * @param {string} folderPath
 * @returns {Promise<number>}
 */
async function calculateFolderIntegrity(folderPath) {
  return (await fs.glob('**/*', {cwd: folderPath})).length;
}

/**
 * @param {string} filePath
 * @returns {Promise<string>}
 */
async function calculateFileIntegrity(filePath) {
  return await fs.hash(filePath);
}

/**
 * @param {string} currentPath
 * @param {import('@appium/types').StringRecord} expectedIntegrity
 * @returns {Promise<boolean>}
 */
async function isAppIntegrityOk(currentPath, expectedIntegrity = {}) {
  if (!(await fs.exists(currentPath))) {
    return false;
  }

  // Folder integrity check is simple:
  // Verify the previous amount of files is not greater than the current one.
  // We don't want to use equality comparison because of an assumption that the OS might
  // create some unwanted service files/cached inside of that folder or its subfolders.
  // Ofc, validating the hash sum of each file (or at least of file path) would be much
  // more precise, but we don't need to be very precise here and also don't want to
  // overuse RAM and have a performance drop.
  return (await fs.stat(currentPath)).isDirectory()
    ? (await calculateFolderIntegrity(currentPath)) >= expectedIntegrity?.folder
    : (await calculateFileIntegrity(currentPath)) === expectedIntegrity?.file;
}

/** @type {import('@appium/types').DriverHelpers} */
export default {
  configureApp,
  isPackageOrBundle,
  duplicateKeys,
  parseCapsArray,
  generateDriverLogPrefix,
};

/**
 * @typedef RemoteAppProps
 * @property {Date?} lastModified
 * @property {boolean} immutable
 * @property {number?} maxAge
 * @property {string?} etag
 */

/**
 * @typedef RemoteAppData Properties of the remote application (e.g. GET HTTP response) to be downloaded.
 * @property {number} status The HTTP status of the response
 * @property {import('stream').Readable} stream The HTTP response body represented as readable stream
 * @property {import('axios').RawAxiosResponseHeaders | import('axios').AxiosResponseHeaders} headers HTTP response headers
 */
